主题
当 LLM 在同一轮返回多个工具调用时,我不再一个个串行跑,而是用线程池并行执行,缩短多工具任务的墙钟时间。核心是 ToolRegistry.executeTools,它是 ReAct / Plan / Multi-Agent 三条执行路径共用的统一入口——绝不手写 for 循环。
并行工具执行流程
分五步。
- 分叉:只有一个工具调用就直接同步执行,不走线程池,省掉无谓的线程开销;多个才走并行。
- 建池:
Executors.newFixedThreadPool(min(size, 4)),并发上限MAX_PARALLEL_TOOLS = 4,线程设成 daemon。 - 提交:每个工具包成一个
Callable<ToolExecutionResult>,任务体里先查取消状态,再走executeToolOutput(也就是 HITL → 策略 → executor 那条链)。 - 带超时的批量等待:
executor.invokeAll(tasks, 90, TimeUnit.SECONDS),批次超时 90 秒。 - 按序收结果:靠
invokeAll的契约——返回的Future列表顺序等于提交任务顺序,所以按索引future.get,结果顺序天然等于原始 tool_call 顺序。
【主动抛深度点 1:结果为什么要保序】 这是并行工具最容易踩的坑。并行执行时谁先完成是不确定的,但回灌结果时必须严格按原始 tool_call 顺序——因为 tool(toolCallId) 要和 assistant 消息里的 tool_calls[i] 一一对应,顺序一乱,LLM 就会把 A 的结果当成 B 的。所以我的方案是「执行可以乱序完成,收集必须保序」:invokeAll 的 Future 顺序固定,最后按索引取,天然保序。
【主动抛深度点 2:单个超时不拖垮整批】 如果 A 工具卡死了,我不希望它拖累已经跑完的 B、C。所以 invokeAll 超时后,超时的那个 Future 被取消、返回一个 timedOut 结果占位(「工具执行超时,已取消」),已完成工具的结果原样保留。也就是「部分成功、部分超时」是允许的,批次不会因为一个工具失败就整体失败。
结果顺序怎么保证不乱?
靠 invokeAll 的契约:它返回的 Future 列表顺序和提交任务的顺序一致,不管谁先完成。我按索引逐个 future.get 收集,所以结果顺序 = 原始 tool_call 顺序。这是必须的,因为回灌时 tool(toolCallId) 要和 assistant 消息里的 tool_calls[i] 一一对应。
并行上限为什么是 4?
经验值,平衡三件事:加速收益、线程资源开销、以及防止一次性并发太多工具把文件系统或下游 API 打爆。4 是「够快又不会失控」的折中。
为什么单个工具不也走线程池?
为省开销。线程池创建、提交、上下文切换都有成本,单个工具直接同步执行更快,也更好调试。这是「快速路径」优化——大多数情况 LLM 一次只调一个工具,不能为了少数并行场景让每个工具都背上线程池开销。
并行时用户 Ctrl+C 取消怎么办?
CancellationContext 检查贯穿两处:提交任务前检查一次(直接返回失败),每个任务体里执行前再检查一次。所以取消时,未执行的工具统一返回「用户取消了此次工具调用」,正在执行的不被打断到一半,结果列表仍保持完整占位、协议不破。
并行执行时终端输出会串吗?
工具执行本身不直接写 stdout(结果以字符串返回给 Agent),所以这层不会串。真正要处理输出串扰的是 Multi-Agent 那层——它并行的是多个 Agent,每个 Agent 有自己的流式输出,那里才用「每步独立 ByteArrayOutputStream 缓冲、最后按顺序 flush」来解决。并行工具这一层只关心结果数据的保序,不关心终端渲染。
源码锚点(读码定位用,面试不主动讲)
ToolRegistry.java:1205ToolRegistry.java:63